-
Notifications
You must be signed in to change notification settings - Fork 38.6k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Handle redirects in apiserver proxy handler #34987
Conversation
I'll try to take a look at this later today or tomorrow. Also cc @liggitt |
need to take a closer look, but at the very least, this should be opt-in... not all proxy handling should follow redirects server-side |
Makes sense. Here's a couple options:
I'm leaning towards option (1), since the apiserver can expect the redirect in that case. Option (3) could work, but it would add an extra hop to the connection (might be a non-issue?), and the apiserver would need to know if redirect location was a pod or a node. Am I missing anything? /cc @yujuhong |
option 1, feature-gated with CRI-enablement, seems like the best approach. still want to dig into the mechanism (and ideally factor it out of the main flow a little better) |
Option 1 sounds good to me, too. I don't think we need to feature gate this in the apiserver. CRI affects how kubelet integrates with the runtime, and should be feature gated there (on the kubelet side). This should not concern apiserver, and apiserver should not receive redirects (for exec/attach/portforward) at all if the feature is not enabled in kubelet. |
sniffing responses and redirecting is a pretty invasive change (as evidenced by this PR)... it should be gated to protect against issues found while the feature is in beta |
I refactored this based on the discussion here. Redirect interception is now only enabled for exec/attach/port-forward requests, and can be disabled with a feature gate ( |
if err != nil { | ||
return nil, err | ||
} | ||
removeCORSHeaders(resp) | ||
return resp, nil | ||
} | ||
|
||
var _ = net.RoundTripperWrapper(&corsRemovingTransport{}) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
don't drop this, let the compiler help us
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The compiler already enforces this since we use it in a net.RoundTripperWrapper
context, and the net
package name now conflicts with the stdlib net
. If you feel strongly I can alias the package name and add it back.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not seeing the type assertion elsewhere, I'd like to keep it
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
done
) | ||
|
||
var ( | ||
// Default values for recorded features. Every new feature gate should be | ||
// represented here. | ||
knownFeatures = map[string]featureSpec{ | ||
allAlphaGate: {false, alpha}, | ||
externalTrafficLocalOnly: {false, alpha}, | ||
externalTrafficLocalOnly: {true, beta}, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
unrelated?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just something weird about the diffing... I rebased on HEAD and this resolved.
|
||
backendConn, err := proxy.DialURL(h.Location, h.Transport) | ||
if err != nil { | ||
if err := h.handleUpgrade(w, req); err != nil { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This change moves the hijack and close of the request connection before we get a chance to respond to errors dialing the backend. That means we would no longer send API error responses in error cases.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good catch! It turns out this is an existing issue, since the ErrorResponder fails once the connection is hijacked. I added a test case for this, and fixed it by writing the errors to the hijacked connection directly.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for reviewing! Responded to all comments.
|
||
backendConn, err := proxy.DialURL(h.Location, h.Transport) | ||
if err != nil { | ||
if err := h.handleUpgrade(w, req); err != nil { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good catch! It turns out this is an existing issue, since the ErrorResponder fails once the connection is hijacked. I added a test case for this, and fixed it by writing the errors to the hijacked connection directly.
if err != nil { | ||
return nil, err | ||
} | ||
removeCORSHeaders(resp) | ||
return resp, nil | ||
} | ||
|
||
var _ = net.RoundTripperWrapper(&corsRemovingTransport{}) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The compiler already enforces this since we use it in a net.RoundTripperWrapper
context, and the net
package name now conflicts with the stdlib net
. If you feel strongly I can alias the package name and add it back.
) | ||
|
||
var ( | ||
// Default values for recorded features. Every new feature gate should be | ||
// represented here. | ||
knownFeatures = map[string]featureSpec{ | ||
allAlphaGate: {false, alpha}, | ||
externalTrafficLocalOnly: {false, alpha}, | ||
externalTrafficLocalOnly: {true, beta}, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just something weird about the diffing... I rebased on HEAD and this resolved.
The previous flow was intentional... let the error responder report errors during backend establishment, then hijack. The error responder can't write once we've written content to the connection (headers are already committed, and a well formed API error JSON blob after random other content isn't usable) |
func (r *hijackedErrorResponder) Error(err error) { | ||
header := http.Header{} | ||
header.Set("Content-Type", "text/plain") | ||
body := bytes.NewBufferString(err.Error()) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Wasn't this JSON before (or maybe negotiated?)
Hmm, I see. I'll refactor it a bit tomorrow wait until after the BE connection is established to hijack. There was still an error in the old flow though, because the Responder was invoked after the connection was hijacked (here). I guess the hijacking should just be moved so that it's the last step before the proxy is established? |
Yeah, the request construction block might be able to be moved above the hijack. Yeah, hijack at the last possible moment |
Done. PTAL. |
Will finish up review tomorrow |
if err != nil { | ||
h.Responder.Error(err) | ||
h.Responder.Error(fmt.Errorf("error dialing backend: %v", err)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Depending on what causes connectBackend
/connectBackendWithRedirects
to return an error, you may end up writing error dialing backend: error dialing backend: ...
. It would be nice to avoid that if possible.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done.
return true | ||
// Forward raw response bytes back to client. | ||
if _, err = requestHijackedConn.Write(rawResponse); err != nil { | ||
glog.Errorf("Error proxying response from backend to client: %v", err) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
utilruntime.HandleError(fmt.Errorf("error proxying response...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done (What does this give us? Should I update the other error logging in the go routines below too?)
return conn, fmt.Errorf("error dialing backend: %v", err) | ||
} | ||
|
||
if err = beReq.Write(conn); err != nil { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
doesn't this consume and close the original req.Body
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
it proxies the original request's body to the backend. So that's the intention, isn't it?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yes, but connectBackend() is called repeatedly with the same request in cases where the backend returns a redirect
return nil, nil, fmt.Errorf("too many redirects (%d)", redirects) | ||
} | ||
|
||
intermediateConn, err = h.connectBackend(req, location) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
doesn't calling connectBackend
consume the req.Body the first time? won't that cause failures in subsequent calls (or in the final call when the CRI redirect destination doesn't get any body content?)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm, I think so, but this is also consistent with what http.Client.Do
does. That implementation changes the redirected requests to GET (from POST) requests though, and I bet this is why. This shouldn't affect our usage, since these requests don't have bodies anyway. What do you think the best way to deal with it is?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was going to ask the same thing. Maybe we need a test with multiple redirects?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thinking about this a bit more... If we decide to move to HTTP/2 for streaming requests, and if there are no changes to go's HTTP/2 library, then the only means we'll have of streaming data over the wire will be via the request and response bodies (we'd need to implementing muxing on top). Which would mean that when the client has data it wants to send to the server, the original request body needs to be preserved and available. I'm not sure how that would work... Also not sure it needs to stop this from going in.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can't we buffer a limited amount of the body and re-send it again and again? Like n<=1000 bytes. Every server should know what to do after those n bytes. If it asks for more, we continue to assume that there is no redirect.
I would feel more comfortable with something like this at the beginning of the dev cycle as opposed to just before code freeze. We may need to be prepared to revert and redo if things start behaving oddly. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks, addressed comments. Open question about handling request body in redirects.
I would feel more comfortable with something like this at the beginning of the dev cycle as opposed to just before code freeze. We may need to be prepared to revert and redo if things start behaving oddly.
Ack. There is a feature flag if need be, and this code path shouldn't be exercised normally anyway. Also, now is just before feature freeze, we still have a few more weeks of bug fixing, testing and stabilization.
if err != nil { | ||
h.Responder.Error(err) | ||
h.Responder.Error(fmt.Errorf("error dialing backend: %v", err)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done.
return true | ||
// Forward raw response bytes back to client. | ||
if _, err = requestHijackedConn.Write(rawResponse); err != nil { | ||
glog.Errorf("Error proxying response from backend to client: %v", err) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done (What does this give us? Should I update the other error logging in the go routines below too?)
return nil, nil, fmt.Errorf("too many redirects (%d)", redirects) | ||
} | ||
|
||
intermediateConn, err = h.connectBackend(req, location) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm, I think so, but this is also consistent with what http.Client.Do
does. That implementation changes the redirected requests to GET (from POST) requests though, and I bet this is why. This shouldn't affect our usage, since these requests don't have bodies anyway. What do you think the best way to deal with it is?
if err != nil { | ||
return nil, err | ||
} | ||
removeCORSHeaders(resp) | ||
return resp, nil | ||
} | ||
|
||
var _ = net.RoundTripperWrapper(&corsRemovingTransport{}) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
done
|
||
conn, err = proxy.DialURL(location, h.Transport) | ||
if err != nil { | ||
return conn, fmt.Errorf("error dialing backend: %v", err) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
return nil in error scenarios? otherwise we try to double close it, right? (in the defer here, and in the defer in connectBackendWithRedirects())
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good catch. Done.
defer func() { | ||
if err != nil && conn != nil { | ||
conn.Close() | ||
conn = nil |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
assigning nil here doesn't change the returned value (https://play.golang.org/p/q5dnDAYsL6). actually do return nil, ...
in error cases.
edit: I'm wrong, named returns make this work correctly. your call whether you want to switch it to clean up inline and return nil
this LGTM, go ahead and squash we should keep the redirection length limitation issues in mind when continuing the CRI design... |
hmmm
|
Jenkins GCI GCE e2e failed for commit fa825d9. Full PR test history. The magic incantation to run this job again is |
Oops, I forgot to flip the feature flag back on for the test. |
Filed #36187 to track follow up work. |
Squashed. |
Jenkins unit/integration failed for commit 389a54551c70a38611a4efbab6056ae7a950b23e. Full PR test history. The magic incantation to run this job again is |
Jenkins verification failed for commit 389a54551c70a38611a4efbab6056ae7a950b23e. Full PR test history. The magic incantation to run this job again is |
Reran |
|
||
redirectLoop: | ||
for redirects := 0; ; redirects++ { | ||
if redirects == maxRedirects { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Off by one. With maxRedirects==0
we should at least connect once.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
follow up for this is fine
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks, fixed.
Fixed off-by-one. Reapplying LGTM. |
Marking p2 to ensure PR dependency is respected. |
Jenkins GCE e2e failed for commit 6e0702a. Full PR test history. The magic incantation to run this job again is |
Automatic merge from submit-queue |
Automatic merge from submit-queue Use indirect streaming path for remote CRI shim Last step for #29579 - Wire through the remote indirect streaming methods in the docker remote shim - Add the docker streaming server as a handler at `<node>:10250/cri/{exec,attach,portforward}` - Disable legacy streaming for dockershim Note: This requires PR #34987 to work. Tested manually on an E2E cluster. /cc @euank @feiskyer @kubernetes/sig-node
Overview:
This change is required for implementing streaming requests in the Container Runtime Interface (CRI). See design.
For #29579
/cc @yujuhong
This change is